[TOC]
1. 前言
ConcurrentHashMap是线程安全的哈希表
HashMap、HashTable、ConcurrentHashMap对比:
- HashMap是非线程安全的哈希表
- HashTable是线程安全的哈希表,通过synchronized来保证线程安全(synchronized方法),效率较低
- ConcurrentHashMap的哈希表,通过“分段锁”来保证线程安全
2. JDK1.7
2.1 数据结构
1 | public class ConcurrentHashMap<K, V> extends AbstractMap<K, V> |
2.2 内部类
2.2.1 Segment
1 | static final class Segment<K,V> extends ReentrantLock implements Serializable { |
2.2.2 HashEntry
1 | static final class HashEntry<K,V> { |
2.3 核心函数
2.3.1 初始化
1 | public ConcurrentHashMap(int initialCapacity, |
2.3.2 put(K key, V value)
1 | public V put(K key, V value) { |
插入流程如下:
- 首先找到key对应的segment,没有则进行初始化
- 插入key到对应segment的HashEntry数组,在插入前,会先获取segment的互斥锁,插入后再释放锁
- 首先根据hash获取key在HashEntry数组的位置,即链表首节点
- 遍历链表,若key已经存在,则根据onlyIfAbsent判断是否更新值,再返回;否则新建HashEntry节点,再插入到segment中
- 在插入HashEntry节点会进行扩容判断,需要扩容进行rehash(),否则setEntryAt插入hash对应链表表头
- scanAndLockForPut获取锁如下:
1 | private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) { |
- 扩容rehash()
1 | private void rehash(HashEntry<K,V> node) { |
2.3.3 get(Object key)
1 | public V get(Object key) { |
2.3.3 并发性
添加节点的操作 put 和删除节点的操作 remove 都要获取segment 上的独占锁,所以它们是线程安全的。
get 过程中是没有加锁的,我们需要考虑的问题是 get 的时候在同一个 segment 中发生了 put 或 remove 操作。
put 操作的线程安全性。
- 初始化segment,使用了 CAS 来初始化 Segment 中的数组。
- 添加节点到链表的操作是插入到表头的,所以,如果这个时候 get 操作在链表遍历的过程已经到了中间,是不会影响的。当然,另一个并发问题就是 get 操作在 put 之后,需要保证刚刚插入表头的节点被读取,这个依赖于 setEntryAt 方法中使用的 UNSAFE.putOrderedObject。
- 扩容。扩容是新创建了数组,然后进行迁移数据,最后面将 newTable 设置给属性 table。所以,如果 get 操作此时也在进行,那么也没关系,如果 get 先行,那么就是在旧的 table 上做查询操作;而 put 先行,那么 put 操作的可见性保证就是 table 使用了 volatile 关键字。
remove 操作的线程安全性。
如果 remove 破坏的节点 get 操作已经过去了,那么这里不存在任何问题。
如果 remove 先破坏了一个节点,分两种情况考虑。 1、如果此节点是头结点,那么需要将头结点的 next 设置为数组该位置的元素,table 虽然使用了 volatile 修饰,但是 volatile 并不能提供数组内部操作的可见性保证,所以源码中使用了 UNSAFE 来操作数组,请看方法 setEntryAt。2、如果要删除的节点不是头结点,它会将要删除节点的后继节点接到前驱节点中,这里的并发保证就是 next 属性是 volatile 的。
3. JDK1.8
3.1 数据结构
jdk1.8的ConcurrentHashMap不再使用Segment代理Map操作这种设计,整体结构变为HashMap结构,但是依旧保留分段锁的思想。之前版本是每个Segment都持有一把锁,1.8版本改为锁住hash桶的第一个节点 tabAt(table, i)。它可能是Node链表的头结点、保留节点ReservationNode、或者是TreeBin节点(TreeBin节点持有红黑树的根节点)
3.2 内部类
jdk1.8 的节点变为4种:Node链表节点,ReservationNode保留节点,TreeBin节点,红黑树节点TreeNode
- Node 基本节点
1 | //此类的子类具有负数hash值,并且不存储实际的数据,如果不使用子类直接使用这个类,那么key和val不会为null |
- TreeNode 红黑树节点
1 | static final class TreeNode<K,V> extends Node<K,V> { |
- TreeBin
TreeBin的hash值固定为-2,它是ConcurrentHashMap中用于代理操作TreeNode的特殊节点,持有存储实际数据的红黑树的根节点。因为红黑树进行写入操作,整个树的结构可能会有很大的变化,这个对读线程有很大的影响,所以TreeBin还要维护一个简单读写锁,这是相对HashMap,这个类新引入这种特殊节点的重要原因。
1 | // 红黑树节点TreeNode实际上还保存有链表的指针,因此也可以用链表的方式进行遍历读取操作 |
- ForwardingNode 转发节点
ForwardingNode是一种临时节点,在扩容进行中才会出现,hash值固定为-1,并且它不存储实际的数据数据。如果旧数组的一个hash桶中全部的节点都迁移到新数组中,旧数组就在这个hash桶中放置一个ForwardingNode。读操作或者迭代读时碰到ForwardingNode时,将操作转发到扩容后的新的table数组上去执行,写操作碰见它时,则尝试帮助扩容。
1 | static final class ForwardingNode<K,V> extends Node<K,V> { |
- ReservationNode 保留节点
hash值固定为-3,就是个占位符,不会保存实际的数据,正常情况是不会出现的,在jdk1.8新的函数式有关的两个方法computeIfAbsent和compute中才会出现,帮助完成对hash桶的加锁操作
1 | static final class ReservationNode<K,V> extends Node<K,V> { |
3.3 源码分析
3.3.1 初始化
1 | public ConcurrentHashMap() { |
3.3.2 put(K key, V value)
1 | public V put(K key, V value) { |
- 初始化数组
1 | private final Node<K,V>[] initTable() { |
- 链表树化
1 | private final void treeifyBin(Node<K,V>[] tab, int index) { |
- 扩容
1 | private final void tryPresize(int size) { |
此方法支持多线程执行,外围调用此方法的时候,会保证第一个发起数据迁移的线程,nextTab 参数为 null,之后再调用此方法的时候,nextTab 不会为 null。
transfer思路:原数组长度为 n,所以我们有 n 个迁移任务,让每个线程每次负责一个小任务是最简单的,每做完一个任务再检测是否有其他没做完的任务,帮助迁移就可以了,而 Doug Lea 使用了一个 stride,简单理解就是步长,每个线程每次负责迁移其中的一部分,如每次迁移 16 个小任务。所以,我们就需要一个全局的调度者来安排哪个线程执行哪几个任务,这个就是属性 transferIndex 的作用。
第一个发起数据迁移的线程会将 transferIndex 指向原数组最后的位置,然后从后往前的 stride 个任务属于第一个线程,然后将 transferIndex 指向新的位置,再往前的 stride 个任务属于第二个线程,依此类推。
1 | private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) { |
3.3.3 get(Object key)
1 | public V get(Object key) { |
4. 参考
http://www.jasongj.com/java/concurrenthashmap/
